/*
 * Copyright 2010-2020 Alfresco Software, Ltd.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.activiti.runtime.api.impl;

import static java.util.Collections.emptyMap;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.flipkart.zjsonpatch.JsonPatch;
import org.activiti.engine.ActivitiIllegalArgumentException;
import org.activiti.engine.delegate.DelegateExecution;
import org.activiti.engine.impl.bpmn.behavior.MappingExecutionContext;
import org.activiti.engine.impl.bpmn.behavior.VariablesCalculator;
import org.activiti.engine.impl.persistence.entity.VariableInstance;
import org.activiti.spring.process.ProcessExtensionService;
import org.activiti.spring.process.model.ConstantDefinition;
import org.activiti.spring.process.model.Extension;
import org.activiti.spring.process.model.Mapping;
import org.activiti.spring.process.model.ProcessConstantsMapping;
import org.activiti.spring.process.model.ProcessVariablesMapping;
import org.activiti.spring.process.model.VariableDefinition;
import org.activiti.spring.process.variable.VariableParsingService;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ExtensionsVariablesMappingProvider implements VariablesCalculator {

    private static final ObjectMapper objectMapper = new ObjectMapper();

    private static final Logger LOGGER = LoggerFactory.getLogger(ExtensionsVariablesMappingProvider.class);

    private ProcessExtensionService processExtensionService;

    private ExpressionResolver expressionResolver;

    private VariableParsingService variableParsingService;

    private static final Pattern VARIABLE_PATTERN = Pattern.compile("/\\$\\{(\\w+)}");

    public final static String JSON_PATCH_MAPPING_ERROR = "Invalid jsonPatch variable mapping";

    public ExtensionsVariablesMappingProvider(ProcessExtensionService processExtensionService,
                                    ExpressionResolver expressionResolver,
                                              VariableParsingService variableParsingService) {
        this.processExtensionService = processExtensionService;
        this.expressionResolver = expressionResolver;
        this.variableParsingService = variableParsingService;
    }

    protected Optional<Object> calculateMappedValue(Mapping inputMapping,
                                                    DelegateExecution execution,
                                                    Extension extensions) {
        if (inputMapping != null) {
            if (Mapping.SourceMappingType.VALUE.equals(inputMapping.getType())) {
                return Optional.of(inputMapping.getValue());
            }

            if (Mapping.SourceMappingType.VARIABLE.equals(inputMapping.getType())) {
                String name = inputMapping.getValue().toString();

                if (isTargetProcessVariableDefined(extensions, execution, name)) {
                    return Optional.ofNullable(execution.getVariable(name));
                }
            }
        }
        return Optional.empty();
    }

    public Map<String, Object> calculateInputVariables(DelegateExecution execution) {

        Extension extensions = processExtensionService.getExtensionsForId(execution.getProcessDefinitionId());

        Map<String, Object> constants = calculateConstants(execution, extensions);

        if (!extensions.hasMapping(execution.getCurrentActivityId())) {
            return constants;
        }

        if (extensions.shouldMapAllInputs(execution.getCurrentActivityId())) {
            Map<String, Object> variables = new HashMap<>(constants);
            variables.putAll(execution.getVariables());
            return variables;
        }

        Map<String, Object> inboundVariables = calculateInputVariables(execution, extensions);
        inboundVariables = expressionResolver.resolveExpressionsMap(new VariableScopeExpressionEvaluator(execution), inboundVariables);
        inboundVariables.putAll(constants);
        return inboundVariables;
    }

    private Map<String, Object> calculateConstants(DelegateExecution execution,
                                                   Extension extensions) {
        Map<String, Object> constants = new HashMap<>();

        ProcessConstantsMapping processConstantsMapping = extensions.getConstantForFlowElement(execution.getCurrentActivityId());
        for (Map.Entry<String, ConstantDefinition> mapping : processConstantsMapping.entrySet()) {
            constants.put(mapping.getKey(), mapping.getValue().getValue());
        }
        return constants;
    }

    private Map<String, Object> calculateInputVariables(DelegateExecution execution,
                                                        Extension extensions) {
        Map<String, Object> inboundVariables = new HashMap<>();

        ProcessVariablesMapping processVariablesMapping = extensions.getMappingForFlowElement(execution.getCurrentActivityId());

        Map<String, Mapping> inputMappings = processVariablesMapping.getInputs();
        for (Map.Entry<String, Mapping> mapping : inputMappings.entrySet()) {
            Optional<Object> mappedValue = calculateMappedValue(mapping.getValue(),
                                                                execution,
                                                                extensions);
            mappedValue.ifPresent(value -> inboundVariables.put(mapping.getKey(),
                                                                value));
        }
        return inboundVariables;
    }

    private Optional<Object> calculateOutPutMappedValue(Map.Entry<String, Mapping> mappingEntry,
                                                        Map<String, Object> currentContextVariables,
                                                        DelegateExecution execution,
                                                        Extension extensions) {
        Mapping mapping = mappingEntry.getValue();
        if (mapping == null || mapping.getType() == null) {
            return Optional.empty();
        }

        switch (mapping.getType()) {
            case VALUE:
                return Optional.of(mapping.getValue());

            case JSONPATCH:
                return resolvePatchMapping(
                    mappingEntry.getKey(), mapping.getValue(), execution, extensions
                );
            case VARIABLE:
                if (currentContextVariables != null) {
                    return Optional.ofNullable(currentContextVariables.get(mapping.getValue().toString()));
                }
            default:
                return Optional.empty();
        }
    }

    private Optional<Object> resolvePatchMapping(String outputVariableName, Object changesToApply, DelegateExecution execution,
                                                 Extension extensions) {

        Object executionVariableValue = execution != null ? execution.getVariable(outputVariableName) : null;
        Object processVariableCurrentValue = calculateProcessVariableCurrentValue(executionVariableValue, extensions.getPropertyByName(outputVariableName));

        try {
            JsonNode oldNode;
            if (isObjectVariable(processVariableCurrentValue)) {
                oldNode = objectMapper.convertValue(processVariableCurrentValue, JsonNode.class);
            } else {
                oldNode = objectMapper.createObjectNode();
            }

            JsonNode patchNode = objectMapper.convertValue(changesToApply, JsonNode.class);

            replaceVariablesInJsonPath(patchNode, execution, extensions);
            initializePath(oldNode, patchNode);

            JsonNode patchedNode = JsonPatch.apply(patchNode, oldNode);

            return Optional.ofNullable(objectMapper.treeToValue(patchedNode, Object.class));
        } catch (Exception e) {
            LOGGER.error("Error patching variable. Changes to apply: {}, Process variable current value: {}",
                changesToApply, processVariableCurrentValue, e);
            throw new ActivitiIllegalArgumentException(JSON_PATCH_MAPPING_ERROR, e);
        }
    }

    private boolean isObjectVariable(Object variable) {
        return variable instanceof ObjectNode || variable instanceof Map;
    }

    private void replaceVariablesInJsonPath(JsonNode patchNode, DelegateExecution execution, Extension extensions) {
        for (JsonNode patch : patchNode) {
            if (patch.has("path")) {
                String path = patch.get("path").asText();
                String updatedPath = resolvePath(path, execution, extensions);

                // Only update if there was a variable in the path
                if (!path.equals(updatedPath) && patch instanceof ObjectNode) {
                    ((ObjectNode) patch).put("path", updatedPath);
                }
            }
        }
    }

    private String resolvePath(String path, DelegateExecution execution, Extension extensions) {
        Matcher matcher = VARIABLE_PATTERN.matcher(path);
        StringBuilder updatedPath = new StringBuilder();

        while (matcher.find()) {
            String variableName = matcher.group(1); // Extract variable name without `${..}`
            String replacedValue = replacePathVariables(variableName, execution, extensions);
            matcher.appendReplacement(updatedPath, "/" + replacedValue);
        }

        matcher.appendTail(updatedPath);
        return updatedPath.toString();
    }

    private String replacePathVariables(String variableName, DelegateExecution execution, Extension extensions) {
        if (!isTargetProcessVariableDefined(extensions, execution, variableName)) {
            throw new ActivitiIllegalArgumentException(
                String.format("Path variable $%s used in JsonPatch mapping is not defined for the current process", variableName));
        }

        VariableInstance variableInstance = execution != null ? execution.getVariableInstance(variableName) : null;
        if (variableInstance != null) {
            return replaceVariableIfSupported(variableInstance.getValue(), variableInstance.getTypeName(), variableName);
        }

        VariableDefinition propertyObj = extensions.getPropertyByName(variableName);
        return replaceVariableIfSupported(propertyObj.getValue(), propertyObj.getType(), variableName);
    }

    private String replaceVariableIfSupported(Object value, String type, String originalProperty) {
        if (value == null || StringUtils.isBlank(value.toString())) {
            throw new ActivitiIllegalArgumentException(
                String.format("Path variable $%s used in JsonPatch mapping should not be empty", originalProperty));
        }

        String typeLowerCase = type.toLowerCase();
        if ("string".equals(typeLowerCase) || "integer".equals(typeLowerCase)) {
            return value.toString();
        }

        throw new ActivitiIllegalArgumentException(
            String.format("Variable %s of type '%s' is not allowed in JsonPatch mapping. Only string and integer types are allowed", originalProperty, type));
    }


    private void initializePath(JsonNode oldNode, JsonNode patchNode) {
        for (JsonNode patch : patchNode) {
            String path = patch.get("path").asText();
            String[] properties = path.split("/");

            JsonNode currentNode = oldNode;

            for (int i = 1; i < properties.length - 1; i++) {
                String property = properties[i];
                if (isArrayProperty(currentNode, property)) {
                    currentNode = handleArrayPath(property, currentNode);
                } else {
                    if (!currentNode.has(property) || !currentNode.get(property).isObject()) {
                        ((ObjectNode) currentNode).set(property, objectMapper.createObjectNode());
                    }
                    currentNode = currentNode.get(property);
                }
            }
        }
    }

    private boolean isArrayProperty(JsonNode node, String property) {
        return (node.isArray() || (!node.isEmpty() && (node.has(property) && node.get(property).isArray()) || property.matches("\\d+")));
    }

    private JsonNode handleArrayPath(String property, JsonNode currentNode) {
        if (!currentNode.isArray()) {
            return currentNode.get(property);
        }
        int index = Integer.parseInt(property);
        ArrayNode arrayNode = (ArrayNode) currentNode;

        while (arrayNode.size() <= index) {
            arrayNode.add(objectMapper.createObjectNode());
        }

        return arrayNode.get(index);
    }

    public Map<String, Object> calculateOutPutVariables(MappingExecutionContext mappingExecutionContext,
                                                        Map<String, Object> availableVariables) {

        Extension extensions = processExtensionService.getExtensionsForId(mappingExecutionContext.getProcessDefinitionId());

        if (!extensions.hasMapping(mappingExecutionContext.getActivityId())) {
            return emptyMap();
        }

        if (expressionResolver.containsExpression(availableVariables)) {
            throw new ActivitiIllegalArgumentException("Expressions are not allowed as variable values in the output mapping");
        }

        if (extensions.shouldMapAllOutputs(mappingExecutionContext.getActivityId())) {
            return (availableVariables != null ? new HashMap<>(availableVariables) : emptyMap());
        }

        return calculateOutPutVariables(mappingExecutionContext, extensions, availableVariables);
    }

    private Map<String, Object> calculateOutPutVariables(MappingExecutionContext mappingExecutionContext,
                                                         Extension extensions,
                                                         Map<String, Object> availableVariables) {
        Map<String, Object> outboundVariables = new HashMap<>();
        ProcessVariablesMapping processVariablesMapping = extensions.getMappingForFlowElement(
            mappingExecutionContext.getActivityId());
        Map<String, Mapping> outputMappings = processVariablesMapping.getOutputs();
        DelegateExecution execution = mappingExecutionContext.getExecution();

        for (Map.Entry<String, Mapping> mappingEntry : outputMappings.entrySet()) {
            String name = mappingEntry.getKey();

            if (isTargetProcessVariableDefined(extensions, execution, name)) {
                calculateOutPutMappedValue(mappingEntry, availableVariables, execution, extensions).ifPresent(
                    value -> {
                        extensions.getProperties().values().stream().filter(v -> v.getName().equals(name)).findAny().ifPresentOrElse(
                            v -> outboundVariables.put(name, variableParsingService.parse(new VariableDefinition(v.getType(), value))),
                            () -> outboundVariables.put(name, value)
                        );


                    });
            }
        }

        return resolveExpressions(mappingExecutionContext, availableVariables, outboundVariables);
    }

    private Object calculateProcessVariableCurrentValue(Object executionVariableValue, VariableDefinition propertyVariableDefinition) {
        return !isProcessVariableNull(executionVariableValue) ? executionVariableValue : propertyVariableDefinition.getValue();
    }

    private boolean isProcessVariableNull(Object variable)  {
        return variable == null || NullNode.getInstance().equals(variable);
    }

    private Map<String, Object> resolveExpressions(MappingExecutionContext mappingExecutionContext,
                                                   Map<String, Object> availableVariables,
                                                   Map<String, Object> outboundVariables) {
        if (mappingExecutionContext.hasExecution()) {
            return resolveExecutionExpressions(mappingExecutionContext, availableVariables, outboundVariables);
        } else {
            return expressionResolver.resolveExpressionsMap(
                new SimpleMapExpressionEvaluator(availableVariables), outboundVariables);
        }
    }

    private Map<String, Object> resolveExecutionExpressions(MappingExecutionContext mappingExecutionContext,
                                                            Map<String, Object> availableVariables,
                                                            Map<String, Object> outboundVariables) {
        if (availableVariables != null && !availableVariables.isEmpty()) {
            return expressionResolver.resolveExpressionsMap(
                new CompositeVariableExpressionEvaluator(
                    new SimpleMapExpressionEvaluator(availableVariables),
                    new VariableScopeExpressionEvaluator(mappingExecutionContext.getExecution())),
                outboundVariables);
        }
        return expressionResolver.resolveExpressionsMap(
            new VariableScopeExpressionEvaluator(mappingExecutionContext.getExecution()), outboundVariables);
    }

    private boolean isTargetProcessVariableDefined(Extension extensions,
                                            DelegateExecution execution,
                                            String variableName) {
        return extensions.getPropertyByName(variableName) != null ||
            (execution != null
                && execution.getVariable(variableName) != null);
    }
}
